Skip to content

Wire the Product CDM manifest and contract resolver - #113

Merged
knzeng-e merged 13 commits into
devfrom
feat/product-cdm-manifest
Aug 3, 2026
Merged

Wire the Product CDM manifest and contract resolver#113
knzeng-e merged 13 commits into
devfrom
feat/product-cdm-manifest

Conversation

@knzeng-e

@knzeng-e knzeng-e commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Outcome

The Product CDM runtime adapter is now backed by a real contract resolver over
a generated cdm.json snapshot, selectable with
VITE_DOTIFY_RUNTIME_ADAPTER=product-cdm. It stays off by default. Catalog
reads can route through Product SDK contract handles; contract writes stay on
the viem signer path.

This is the Phase 4 "wire a generated CDM manifest/types into Product mode"
item from docs/backlog/polkadot-product-readiness-and-killer-dapp-roadmap.md
(#85). Stacked on #112.

Issue and context

The previous branch left ProductCdmRuntimeAdapter unreachable: it knew how to
map Dotify's runtime surface onto Product contract handles, but nothing could
produce those handles. createProductCdmRuntimeContractResolver documented the
gap explicitly.

The roadmap framed the blocker as needing CDM-installed packages. That turned
out to be wrong, and the correction is the main design decision here.

Architecture and key concepts

A generated snapshot manifest, not cdm install. Dotify's Solidity
contracts are deployed through Asset Hub's eth-rpc, which is a compatibility
layer over pallet-revive - the same pallet the Product SDK contract helpers
target. The deployed H160 addresses are therefore already reachable through
@parity/product-sdk-contracts with no PolkaVM recompilation and no registry
entry. CdmJsonContract needs only version, address, and abi for
getContract(), and new ContractManager(...) is documented as snapshot-only.

web/scripts/generate-cdm-manifest.mjs emits that snapshot from the same
Hardhat artifacts the viem bindings come from, so the two adapters cannot
disagree about an ABI.

Output Contents
cdm.json @dotify/artist-directory, @dotify/artist-runtime-factory at their deployments.json addresses
smartRuntime.ts merged artist-runtime diamond facet ABI (41 entries)
cdm.d.ts Contracts module augmentation for typed handles

Artist runtimes are deliberately absent from the manifest: a diamond is deployed
per artist, so its address is known at call time, not build time. A placeholder
address would misrepresent the deployment.

flowchart LR
  Artifacts[Hardhat artifacts] --> Gen[generate-cdm-manifest]
  Deployments[deployments.json] --> Gen
  Gen --> Manifest[cdm.json + cdm.d.ts]
  Gen --> Facets[merged facet ABI]
  Manifest --> Manager[ContractManager<br/>fixed addresses]
  Facets --> Create[createContract<br/>per-artist diamond]
  Manager --> Resolver[ProductCdmContractResolver]
  Create --> Resolver
  Resolver --> Adapter[ProductCdmRuntimeAdapter]
  Adapter --> Ports[RuntimeReadPort]
Loading

Design decisions and tradeoffs

Build-time selection, not runtime. Two reasons. Switching the authority for
access policy is a deployment decision an operator makes with evidence, not
something a page should be able to flip. And Vite inlines the value, so a
viem build tree-shakes the entire Product contract graph away:

Build Output
unset or viem 4.4 MB
product-cdm 10 MB

The 5.6 MB difference is @parity/product-sdk-descriptors, whose shared
descriptors module references every chain's metadata. Only one chunk is fetched
at runtime, but all are published, and Bulletin storage is a finite quota.
Both shipped builds stay at 4.4 MB.

Reads only. RuntimeWritePort has no equivalent provider on purpose.
Routing a payment or a publication through a signer with no host transaction
evidence is not a reasonable default.

No fallback on failure. A failed Product setup rejects every read with that
error rather than degrading to viem. The adapter in use must never be
ambiguous, because the artist runtime is the authority on access policy.

A lazy facade instead of call-site churn. Every RuntimeReadPort method
already returns a promise, so the Product adapter's async setup hides behind a
facade rather than turning ~16 call sites into double-awaits.

Two SDK constraints that shaped this

  1. createChainClient routes exclusively through the Product host provider
    and throws when none is present - there is no direct-WebSocket fallback.
    Product mode is therefore impossible in a standalone build, and
    validateProductionEnvironment now rejects product-cdm unless
    VITE_DOTIFY_HOST_MODE is enabled.

  2. The host decides which chain an environment resolves to, and only
    devnet is correct - see the follow-up commit below. A wrong environment
    resolves every manifest address to an account with no code, which reads as
    artists with no releases. verifyDeployment() queries artistCount before
    any catalog read and fails closed with a named error.

createChainClient is used rather than the zero-config getChainAPI because
the preset table references every environment's assetHub, bulletin, and
individuality descriptors.

Corrections to the previous branch's notes

Both "unverified" markers I added on #112 were resolved by reading the SDK
types, and the code was already right:

  • payForAccess - TxOptions carries value?: bigint and methods take
    positional args followed by an options object, so [contentHash, { value }]
    is correct.
  • waitForTransaction - .tx() resolves at best-block by default and
    TxResult carries the including block, so txContract has already awaited
    inclusion. The no-op is safe by construction, not an omission. That is the
    same point viem's waitForTransactionReceipt resolves at.

The adapter's structural type mirror also matches the real SDK exactly:
Result is {ok,value}|{ok,error} and QueryResult is
{success,value,gasRequired}.

Separately, Polkadot Hub TestNet is live again - head at block 11,544,296,
past the 10,612,201 freeze, with the ArtistDirectory still returning code. The
existing deployments.json deployment survived; no redeploy was needed.

Review guide

  1. web/scripts/generate-cdm-manifest.mjs - manifest shape, facet merge
    collision check, why unnamed params are renamed for codegen only.
  2. web/src/features/runtime/productCdmContracts.ts - the two resolution paths
    and verifyDeployment.
  3. web/src/features/runtime/runtimeReaderProvider.ts - the build-time gate and
    the no-fallback property.
  4. web/src/features/runtime/runtimeAdapterConfig.ts +
    deploymentSafety.ts - fail-closed selection and the host-mode requirement.

Verify carefully

  • A viem build still contains no Product contract or descriptor chunks.
  • product-cdm without a host mode is rejected at build time.
  • An unknown adapter value falls back to viem rather than disabling reads.
  • Writes still go through the viem signer in every mode.
  • cdm.json addresses match deployments.json.

Validation

Evidence Result
cd web && npm run test:unit 216 pass (was 215 on #112; +14 over main, covering adapter selection, the resolver, the build-time gate, and the no-fallback property)
cd web && npm run lint 0 errors; the 3 pre-existing App.tsx/ArtistShell.tsx warnings remain
cd web && npm run build pass - 4.4 MB, 0 chain-metadata chunks, Product graph tree-shaken
cd web && npm run build:product-devnet pass - 4.4 MB
VITE_DOTIFY_RUNTIME_ADAPTER=product-cdm npm run build:product-devnet pass - 10 MB, CDM chunk + 4 metadata chunks present
cd web && npm run smoke:production-env pass
cd services/api && npm run typecheck && npm test pass, 92 tests
cd contracts/evm && npm run fmt:check pass
node scripts/backlog-sync.mjs --check --offline pass; pre-existing warnings remain
git diff --check clean

Chain liveness confirmed read-only against eth-rpc-testnet.polkadot.io:
eth_blockNumber = 11544296, eth_chainId = 420420417, and eth_getCode on
the directory returns bytecode.

Known limitations and follow-ups

Not smoke-tested against a real Product host. Every Product path here is
exercised through injected dependencies, not a live container. The resolver
cannot be run outside a host at all, by SDK design.

The chain question is settled - and the answer removed a blocker. Product
DevNet is not a separate network: it is a preset over the Paseo system
parachains (Asset Hub 1000, People 1004, Bulletin 1010) at EVM chain
420420417, which is exactly where Dotify is already deployed. Verified
read-only: eth-rpc-testnet.polkadot.io and
paseo-assethub-rpc.laissez-faire.trade return the same chain id, blocks one
apart, and byte-identical ArtistDirectory code. No contract redeploy is
needed to port Dotify to DevNet.

The correction is that the SDK's paseo preset targets Paseo Next (Asset Hub
Next 1500 / People Next 1502) - a different network per the Product docs. The
default has been changed to devnet and paseo is no longer selectable.

The only gate left for Product contract mode is pallet-revive account mapping
plus real host-signed transaction evidence, before writes can move off the EVM
wallet path.

npm run generate:cdm must be re-run after any contract redeploy, or cdm.json
addresses go stale. This is not yet enforced by CI the way generate:abis is.


Follow-up: 9246537 corrects the chain preset

Review round on this branch found the default productEnvironment was wrong,
in a way that would have produced an empty catalog rather than an error at
build time.

Product DevNet is a preset, not a network. It targets the Paseo system
parachains - Asset Hub (1000), People (1004), Bulletin (1010) - at EVM chain
420420417. Verified read-only on 2026-07-29 against the ArtistDirectory at
0xcf1534c6e2b0e43b9436c1e86a076466dc0f2108:

Endpoint eth_chainId Block Directory bytecode
https://eth-rpc-testnet.polkadot.io/ 0x190f1b41 11546347 3660 chars, sha256 36707b24…
https://paseo-assethub-rpc.laissez-faire.trade 0x190f1b41 11546348 3660 chars, sha256 36707b24…

Two providers, one chain. Dotify's deployments.json addresses are already
DevNet addresses; no redeploy is required.

The SDK's paseo preset is Paseo Next v2 (Asset Hub Next 1500 / People
Next 1502). The Product documentation states those "belong to a different
network" and "funds sent there will not appear on this Devnet". Dotify has no
deployment there, so ProductChainEnvironment now admits only devnet and the
config guard refuses anything else - a chain that cannot hold the catalog is
not a configuration option.

Also realigns the environment reference tables, clearing three markdownlint
MD060 warnings.

Re-verified after the correction: 216 web tests, 92 API tests, 0 lint errors,
both shipped builds still 4.4 MB, backlog sync clean.

knzeng-e and others added 13 commits July 26, 2026 21:22
The Host signRaw wire format is not pinned by the SDK: HostSignPayloadResponse
carries an untagged signature, and a Substrate host may sign a raw payload
verbatim or inside the conventional <Bytes> envelope. Verification assumed one
shape, so a wrong guess would have failed every Product key request with an
error indistinguishable from a wrong signer.

Accept a bounded set instead: the canonical message verbatim or <Bytes>-wrapped,
and a bare 64-byte or MultiSignature-tagged 65-byte sr25519 signature. Every
variant carries the identical domain-bound message, so this adds no replay,
cross-app, cross-chain, or cross-track surface; a non-sr25519 tag still fails
closed. Route schemas widen to 128 or 130 hex so the tag is checked by the
verifier rather than rejected before it.

Reject EVM-derived account ids for product-sr25519-v1. A 20-byte H160 padded
with 0xee derives back to the H160 it contains, so accepting that shape let a
caller name any paying EVM listener as the requester and rested the boundary on
the curve check alone. A real Product account is a native AccountId32.

A key that parses and derives to the requester but verifies under no variant now
returns PRODUCT_SIGNATURE_REJECTED, kept distinct from SIGNATURE_INVALID so an
envelope problem is separable from a wrong-account problem in logs.

Pin @scure/sr25519 exactly, matching @noble/hashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wiring the Product key signatures changed the behaviour but three places still
described the old one. The wallet modal told Product-host users that protected
playback required an EVM signer, the runbook asked operators to confirm no key
is released through the Product identity, and the architecture matrix said the
shipped UI used EIP-191 or a session token - contradicted by its own prose two
sections later.

All three now describe what ships: a connected Product account requests
protected keys through product-sr25519-v1, and paid access plus artist
publishing remain on the EVM signer. This matters beyond tidiness - the stale
runbook step would have had an operator sign off on a denial as correct
behaviour, hiding a real signing failure.

Record the signing envelope decision and the EVM-derived key rejection, and turn
the runbook step into an evidence capture that names which envelope the live
host actually produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Track and split counts come from contract storage and the directory enumerates
runtimes Dotify does not control, so Array.from({ length: Number(count) })
allocated before anything could reject a malformed or hostile value. Both
adapters now validate counts first and throw rather than truncate, since a
silent cap would present a partial catalog as complete. The catalog loader
already isolates per-runtime failures, so one bad runtime degrades to a missing
artist.

Mark the two unverified spots in the CDM adapter that must be settled before it
can be selected: waitForTransaction returns immediately where the viem writer
awaits a receipt, and the payForAccess value-transfer shape is inferred rather
than confirmed against generated contract types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dotify has no CDM-registered packages and no `cdm install`, but it does not
need them. Its Solidity contracts are deployed through Asset Hub's eth-rpc,
which is a compatibility layer over pallet-revive - the same pallet the Product
SDK contract helpers target - so the deployed H160 addresses are already
reachable without PolkaVM recompilation or a registry entry.

CdmJsonContract needs only version, address, and abi for getContract(), and
`new ContractManager(...)` is documented as snapshot-only. The generator emits
exactly that snapshot from the same Hardhat artifacts the viem bindings come
from, so the two adapters cannot disagree about an ABI.

Artist runtimes are deliberately absent from the manifest: a diamond is
deployed per artist, so its address is known at call time, not build time, and
a placeholder would misrepresent the deployment. Their merged facet ABI is
emitted separately and bound to an address by createContract.

The generator lives in web/ because it needs the SDK's codegen, which is a
frontend dependency; adding the Product SDK tree to contracts/evm just to emit
types would be a worse trade. Unnamed Solidity getter params are named
positionally for codegen only - generateContractTypes interpolates the name
into a tuple label and emits `args: [: HexString]`, which does not parse. The
manifest ABI stays byte-faithful to the artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the contract resolver the CDM adapter was missing. Fixed-address
contracts resolve from the generated manifest through ContractManager;
per-artist runtime diamonds bind the merged facet ABI to their call-time
address through createContract.

Two SDK constraints shape this. First, createChainClient routes exclusively
through the Product host provider with no direct-WebSocket fallback, so Product
mode cannot work in a standalone build - validateProductionEnvironment now
rejects product-cdm unless the host mode is enabled. Second, the host decides
which chain an environment resolves to, and Dotify's runtimes live on Polkadot
Hub TestNet, reached through the `paseo` preset rather than `devnet`.
verifyDeployment queries the directory before any catalog read so a wrong-chain
connection fails with a named error instead of looking like artists with no
releases.

Selection is build-time rather than runtime, for two reasons. Switching the
authority for access policy is a deployment decision made with evidence, not
something a page should flip. And Vite inlines the value, so a viem build
tree-shakes the whole Product graph away: 4.4 MB against 10 MB when opted in.
The difference is @parity/product-sdk-descriptors, whose shared descriptors
module references every chain's metadata - only one chunk is ever fetched, but
all are published, and Bulletin storage is a finite quota. Both shipped builds
stay at 4.4 MB.

Reads only. Writes stay on the viem signer path in every mode, because routing
a payment or a publication through a signer with no host transaction evidence
is not a reasonable default. A failed Product setup rejects every read rather
than falling back to viem: the adapter in use must never be ambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The blocking unknown for Product contract mode is no longer the manifest or
generated types - both now exist and are wired. It is whether the Product host
serves a chain that holds Dotify's runtimes, since the host controls that
mapping and the contracts are on Polkadot Hub TestNet rather than Product
DevNet Asset Hub.

Also records the measured build-size trade-off, so an operator weighs it
against the Bulletin quota before enabling product-cdm for a .dot deployment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous default was wrong in a way that would have produced an empty
catalog. Product DevNet is not a separate network: it is a preset over the
Paseo system parachains - Asset Hub (1000), People (1004), Bulletin (1010) -
at EVM chain 420420417. That is exactly where Dotify is already deployed.

Verified read-only against both endpoints for the ArtistDirectory at
0xcf1534c6e2b0e43b9436c1e86a076466dc0f2108: eth-rpc-testnet.polkadot.io and
paseo-assethub-rpc.laissez-faire.trade both report chain id 0x190f1b41, blocks
one apart, and byte-identical contract code. They are two providers for one
chain, so no contract redeploy is needed to port Dotify to DevNet.

The SDK's `paseo` preset is the trap: it targets Paseo Next (Asset Hub Next
1500 / People Next 1502), which the Product docs call a different network where
"funds sent there will not appear on this Devnet". Dotify has no deployment
there, so ProductChainEnvironment now admits only `devnet` - selecting a chain
that cannot hold the catalog is a bug, not a configuration option.

Also realigns the environment reference tables, clearing the markdownlint
MD060 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@knzeng-e
knzeng-e changed the base branch from feat/product-key-signature-client to dev August 3, 2026 13:33
@knzeng-e
knzeng-e marked this pull request as ready for review August 3, 2026 13:33
@knzeng-e
knzeng-e merged commit 7d410dc into dev Aug 3, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant